Convert an existing website to Docly
What you'll see
A customer has a working website on another platform and wants it rebuilt in Docly. The content is there, the design is approved, and the URLs have rankings. The tempting move is to copy the markup across, wire it to a template and call it converted. That produces a site that looks roughly right, drifts from the original in ways nobody measures, and carries the old platform's dependencies and defects into the new one.
What's actually happening
A conversion has three obligations that pull in different directions, and all three have to hold at once.
The technique is rebuilt, not copied. The old markup, scripts, folder structure and naming conventions do not come across. They encode another platform's constraints. Docly has its own model — schema-bound documents, display templates, master pages, the #/ tree — and the conversion is only worth doing if the result is a genuine Docly solution rather than the old site wearing a new hat.
The visual result is identical. The customer approved that design. A conversion that looks almost the same is a redesign nobody ordered, and the differences surface after launch when they are expensive. Pixel-perfect is the default, and it is a measurement, not an impression.
The content becomes editable. If the customer could edit the old site and cannot edit the new one, the conversion moved backwards. Every piece of text, every price, every phone number belongs in a schema-bound document, not in a .hash file.
Why "it looks the same" is not verification
Eyeballing a screenshot catches gross errors and nothing else. In one conversion the front page matched on five measured elements and was declared pixel-perfect — while the service section rendered as description cards instead of the original's six-button grid, the call-to-action was a filled button instead of an outlined one, the footer had the wrong structure entirely, and every subpage used a different background image that had not been noticed at all.
Each page type generally has its own template. Front page, subpage and detail page routinely differ in heading treatment, font sizes, column widths and background image. Measuring one page tells you about one page.
Third-party dependencies do not survive the trip
The target is A+ on securityheaders.com, which means a Content-Security-Policy without unsafe-inline. That single requirement rules out inline <style> blocks, style= attributes, inline scripts, CDN stylesheets, web-font services and embedded widgets. Fonts get self-hosted. Widgets get replaced or dropped. This is not a preference to be traded away for convenience — one inline attribute forces unsafe-inline into the policy and the rating collapses.
The old site's defects are findings, not specifications
Legacy sites carry bugs that a faithful conversion would faithfully reproduce. Real examples from one conversion: the home page served identical content at two URLs with no canonical; nine pages had double-encoded meta descriptions rendering as Håndvask in search results; six pages shared one 540-character description; the sitemap omitted a third of the pages; the price list existed in the markup but sat inside a zero-height container and never displayed; jQuery loaded twice.
Fix them, and record each one — what it was, what it cost, what you did. That list is one of the most valuable things the customer gets out of the project.
What to do
Step zero: set up the workspace's instruction files
Before anything is built, create .claude/CLAUDE.md and .claude/docly.md in the workspace as described in Set up AI agent instruction files for a workspace. Record the environment there: source URL, the URL the workspace publishes on now, the domain it takes over at launch, and whether the site is live yet.
Skip this and the next session starts blind — it will re-derive the environment, guess at the conventions, and ask the customer questions that were already answered once.
Ask these before you start
Only the things that cannot be looked up. Ask them together, up front, not one at a time as you hit them:
- Which URL does this workspace publish on today, and which domain does it take over at launch?
- Is the existing design approved as-is? Pixel-perfect is the default; a redesign has to be stated explicitly.
- Who receives contact-form submissions, and is SMTP configured? Without this the form stores enquiries but notifies nobody.
- Is there source material in higher resolution than what is on the web — logo, photography?
- Is there content on the old site that should not come across, or content that is missing and should be added?
- Anything on the old site that is deliberately the way it is, even though it looks wrong?
Never ask about these — decide them yourself
URL structure, folder and file names, schema design, which field type to use, where a file goes, whether something should be a document or a template, and the order of work. All of it follows from this entry and the documentation. Asking costs the customer time and produces no better answer.
What does go back to the customer is business fact: contradictions in the source content, content with no obvious home, and launch decisions. Collect them and present them together at the end, in the conversion report.
Order of work
- Map the source. Start with
robots.txtandsitemap.xml, then crawl — sitemaps are routinely incomplete. Capture every page, its text, its images, its meta description and its URL. - Capture the design in the browser. Fonts, colours, background images and computed styles on headings, body text and buttons. Read the values; do not estimate them.
- Build schemas first, then templates, then content documents.
- Measure every page against its original at each milestone.
- Publish a conversion report in the new solution.
Verification that actually verifies
Read getBoundingClientRect() for each significant element on the original, read the same on the new page, and subtract. All four numbers — y, x, width, height — must be zero.
const g = s => { const e = document.querySelector(s); const b = e.getBoundingClientRect();
return [Math.round(b.y + scrollY), Math.round(b.x), Math.round(b.width), Math.round(b.height)]; };
// original: h1 = [204, 463, 980, 32]
g('h1').map((v, i) => v - [204, 463, 980, 32][i]); // -> [0, 0, 0, 0] Do this for every page, not a sample. Anything that will not reach zero is a deviation: write it down with the reason. A recorded deviation is a decision; an unrecorded one is a defect.
Structure
Tjenester/ <- editor-visible, documents only
Sider/ <- editor-visible
Innstillinger/ <- company info, price list
#/
site.json <- security headers
master.hash
<Schema>.hash <- display templates, in the ROOT of #/
Schemas/
Folder/index.hash
Root/ <- everything published: index.hash, style.css, 404.hash
API/ The #/ tree is invisible to non-developers, so everything editable must live outside it. See Separate code from content for non-developer users.
Platform behaviour worth knowing before you start
Per-page background images without inline style. Set a class on <body> from the child page and style it in the stylesheet:
<body class="subpage-bg" xdt:Transform="SetAttributes(class)"> url() in an external stylesheet is not rewritten with the mount path the way href and src in HTML are — it resolves against the stylesheet's own address. Use relative paths (url("images/bg.jpg")) so they work both under a subfolder mount and later on the customer's own domain.
Cache-bust the stylesheet with docly.assetUrl("/style.css"). Without it the browser serves the previous CSS and you will debug a change that is already correct on the server. Fetch the served stylesheet and check before concluding that an edit did not work. See Cache-busting bundles with assetUrl.
A content folder beats a Root page of the same name. With a Tjenester/ folder present, #/Root/Tjenester.hash returns 404; the landing page must be #/Root/Tjenester/index.hash.
Document URLs need redirecting. Documents that exist only as a source for a Root page are still served at their own URL, which is duplicate content. Make the display template redirect to the canonical page.
API endpoints: four traps that all produce a bare 500
- Form fields are array-like, not strings. On
application/x-www-form-urlencoded,form.Nameis a .NET array-like object.Array.isArray()returns false on it andString(v)yields an empty string — index it directly. saveJsonneedsensurePath. The signature issaveJson(path, data, ensurePath); withouttrueit throws when the folder does not exist.docly.redirectdoes not add the mount path. Userequest.sitepath + "Page".- Write functions exist only in the API context.
docly.saveJsonisundefinedin.hashfiles.
Query parameters are unavailable in .hash — query and request.query are undefined and request.rawurl is stripped. Use dedicated pages for confirmation and error states rather than ?sent=1.
Finish with a conversion report
Publish it in the new solution at a fixed URL — /_conversion works well — with noindex,nofollow and no link from the menu. Generate the tables from the documents with getFiles() rather than typing them out, so the report cannot drift from what the solution actually contains.
Cover: environment and URLs; the content model (schema → folder → document count); a full content listing; the URL map with redirects; defects found in the old solution; the security headers actually served; the pixel measurements element by element with the deviation in numbers; and the open items with who has to resolve each one.
Note in the report that it is readable by anyone who knows the address, and that it can be closed with denyAccess if the customer prefers.
Forms
Use Docly''s built-in form submission. Do not write your own submit endpoint. data-smtp mails the submission and data-validate runs a server-side validation function in #/API/ before the form is accepted. A hand-written endpoint reimplements the platform and gets the ordering, the redirects or the error paths wrong.
Every form is protected with Cloudflare Turnstile, verified server-side, failing closed while a test key is in place. See Protect every form with Turnstile. Submissions are never written into the file tree.
No counter loops with index lookups
for (var i = 0; i < a.length; i++) reaching into a[i] is out. Iterate over the elements instead.
for (let x of list) is the first choice; .forEach() is fine too. Both work, and the engine takes let and const.
for (let d of docly.getFiles("/Settings")) {
if (d.fileschema == "CompanyInfo") { info = d; }
} In hash templates the loop spans the block boundary — open it in one block, close it in the next:
#{ for (let s of services) { }#
<a href="#s.Url#">#docly.htmlEncode(s.Title)#</a>
#{ } }# Last step: re-read the guidelines against what you built
Before calling it finished, open the KB entries again and check the work against them — from the page, not from memory. The violations that survive to the end are the ones nobody re-read for.
- No
<style>, nostyle=, no inline<script> - No third-party resources — fonts and icons self-hosted
- Security headers in
#/site.json, A+ met - No counter loops with index lookups —
for...ofor.forEach() - Every form has Turnstile, verified server-side, failing closed on test keys
- No submissions stored in the file tree; secrets only under
#/ - No document URL duplicating a page
- Every page measured against the original, deviation written out in numbers
- Animations reproduced
- Scratch files deleted and their URLs returning 404
- Conversion report published;
CLAUDE.mdandAGENTS.mdreturn 404